本节我们学习两种关键的基本算法思想:DFS(深度优先搜索)和BFS(广度优先搜索)。这两种算法和栈、队列有着千丝万缕的关系,如果前两节你认真学习掌握了,那么这一节对你来说相信不是问题。

# 深度优先搜索思想:不撞南墙不回头的“迷宫游戏”

30 秒速记

  • DFS(深度优先)= 走迷宫一条路走到底,撞墙了退回最近的岔路口换一条
  • 实现:递归(借函数调用栈)或手写栈,本质都是后进先出
  • 图里有环,必须在入栈时标记 visited,否则会重复访问甚至死循环
  • 复杂度 O(V + E);递归深度由树高或最长路径决定
  • 适合枚举路径、连通性、回溯;不保证无权图最短路,最短路找 BFS

DFS 就是走迷宫的笨办法:认准一条路往里走,走不通就退回上一个岔路口换条路。 实现上有两种写法,一种是递归,函数调用栈帮你记住回退点;一种是自己维护一个数组当栈,pop 一个处理一个,把邻居 push 进去。两种本质一样,都是后进先出。遍历图的时候我会在节点第一次入栈时就加进 visited,不然有环的图会转圈。它能找到「一条」出路,但不保证是最短的,要最少步数得换 BFS。

回答参考:“DFS 的核心不是递归语法,而是后进先出的待办集合。我会先定义访问标记时机,再说明当前路径与已完成节点各代表什么。”

function dfs(graph, start) {
  if (start == null) return []
  const stack = [start]
  const visited = new Set([start])
  const order = []

  while (stack.length) {
    const node = stack.pop()
    order.push(node)
    const neighbors = graph.get(node) ?? []
    for (let i = neighbors.length - 1; i >= 0; i -= 1) {
      const next = neighbors[i]
      if (!visited.has(next)) {
        visited.add(next)
        stack.push(next)
      }
    }
  }
  return order
}
@前端进阶之旅: 代码已经复制到剪贴板

面试官追问

追问 1把递归改成手写 stack 之后,还算 DFS 吗?
参考回答

算。DFS 看的是处理顺序是不是后进先出,不看你有没有写递归。递归只是借用了 JS 引擎的调用栈,手写栈还能躲开 Maximum call stack size exceeded。

追问 2同一张图,递归版顺序是 A B C,改成栈版变成了 A C B,怎么回事?
参考回答

栈后进先出,邻居按 B、C 正序压进去,C 就先弹出来。想和递归顺序一致,就从邻接表末尾往前压:for (let i = nbrs.length - 1; i >= 0; i--)。

追问 3visited 放在 pop() 之后再标记,有什么问题?
参考回答

结果大概率还是对的,但同一个节点会被多个邻居反复压栈,栈的峰值能涨到边数级别。一般在 push 的时候就标记,从源头挡掉重复。

追问 4找到出口后还要把路线画出来,怎么记路径?
参考回答

发现新节点时记一笔 parent.set(next, node),到出口后顺着 parent 倒推回起点再 reverse()。别直接拿栈当路径,普通遍历栈里混着别的分支的待办节点,连不成一条线。

20世纪90年代,小霸王学习机风靡全国。没有哪个小学生,不想拥有一台自己的小霸王学习机——我,也不例外;没有哪个小学生在拥有了小霸王学习机之后,会真的用它来搞学习——我,也不例外。在那个没有王者也没有吃鸡的年代里,小霸王里让我欲罢不能的除了魂斗罗、超级玛丽,还有它——迷宫游戏:

很多同学跟我说他入门算法的时候就挂在 DFS 这里,觉得太高深了,学不动。傻孩子,今天你就会知道,深度优先搜索也不过是在用编码的方式玩一场迷宫游戏。

现在把图上的小黄点想象成你自己,由于你手里没有地图、没有无人机,因此你对迷宫整体的地形一无所知。放眼望去,你眼前只有冰冷的墙壁和并不知道能不能走通的道路。如何走出一条通路?你只能尝试把每一条能走的路都走一遍——也就是所谓的“穷举法”:

  • 以当前位置为起点,闷头往前走
  • 在前进的过程中,难免会遇到岔路口。这个路口可能分叉出去两条、三条、四条甚至更多的道路,你只能选择其中的一条路、然后继续前进(注意,你可能会不止一次遇到岔路口;每遇到一个新的岔路口,你都需要做一次选择)。
  • 你选择的这条路未必是一条通路。如果你走到最后发现此路不通,那么你就要退回到离你最近的那个分叉路口,然后尝试看其它的岔路能不能走通。如果当前的岔路口分叉出去的所有道路都走不通,那么就需要退回到当前岔路口的上一个岔路口,进一步去寻找新的路径。

按照这个思路走下去,只要迷宫是有出口的,你就一定能找到这个出口。在这个过程里,我们贯彻了“不撞南墙不回头”的原则:只要没有碰壁,就决不选择其它的道路,而是坚持向当前道路的深处挖掘——像这样将“深度”作为前进的第一要素的搜索方法,就是所谓的“深度优先搜索”。

深度优先搜索的核心思想,是试图穷举所有的完整路径。

# 深度优先搜索的本质——栈结构

30 秒速记

  • 前进 = 入栈,撞墙退回 = 出栈,DFS 的过程天然就是一串栈操作
  • 迷宫例子里,走到出口时栈里剩下的 A B E G I 就是路径
  • 递归写法靠函数调用栈,迭代写法自己维护数组栈
  • 栈里存「当前路径」还是「待办节点」要想清楚,后者不一定连成一条路
  • 递归太深会爆调用栈,改迭代时可能要在栈帧里多存「下一个要试的邻居」

DFS 的本质是栈:往前走一步就入栈,走不通退回去就出栈。 拿迷宫来说,从 A 走到 B,先试 C,死路,C 出栈退回 B,再试 D,以此类推,最后走到出口 I 时栈里剩下的 A、B、E、G、I 正好是那条通路。递归版本看起来没有栈,其实是 JS 的函数调用栈在帮你压和弹。实际写的时候要分清栈里放的是什么:一次只压一个邻居、退回再试下一个,栈才等于路径;把所有邻居一次性压进去,栈就只是待办清单。

那么如何使用编码来实现深度优先搜索呢?我们继续讨论迷宫问题,这里我给大家一个抽象过后的简单迷宫结构:

图中蓝色的是入口,灰色的是岔路口,黑色的是死胡同,绿色的是出口。

基于眼前的这个迷宫结构,我们来一步一步模拟一下深度优先搜索的具体过程:

  • 从 A 出发,沿着唯一的一条道路往下走,遇到了第1个岔路口B。眼前有三个选择:C、D、E。这里我按照从上到下的顺序来走(你也可以按照其它顺序),先走C。
  • 发现 C是死胡同,后退到最近的岔路口 B,尝试往D方向走。
  • 发现D 是死胡同,,后退到最近的岔路口 B,尝试往E方向走。
  • E 是一个岔路口,眼前有两个选择:F 和 G。按照从上到下的顺序来走,先走F。
  • 发现F 是死胡同,后退到最近的岔路口 E,尝试往G方向走。
  • G 是一个岔路口,眼前有两个选择:H 和 I。按照从上到下的顺序来走,先走H。
  • 发现 H 是死胡同,后退到最近的岔路口 G,尝试往I方向走。
  • I 就是出口,成功走出迷宫。

大家观察一下这个过程,会不会觉得这些前进、后退的操作,其实和栈结构的入栈、出栈过程非常相似呢?

现在我们把迷宫中的每一个坐标看做是栈里的一个元素,用栈来模拟这个过程:

  • 从 A 出发(A入栈),经过了B(B入栈),接下来面临 C、D、E三条路。这里按照从上到下的顺序来走(你也可以选择其它顺序),先走C(C入栈)。
  • 发现 C是死胡同,后退到最近的岔路口 B(C出栈),尝试往D方向走(D入栈)。
  • E 是一个岔路口,眼前有两个选择:F 和 G。按照从上到下的顺序来走,先走F(F入栈)。
  • 发现F 是死胡同,后退到最近的岔路口 E(F出栈),尝试往G方向走(G入栈)。
  • G 是一个岔路口,眼前有两个选择:H 和 I。按照从上到下的顺序来走,先走H(H入栈)。
  • 发现 H 是死胡同,后退到最近的岔路口 G(H出栈),尝试往I方向走(I入栈)。
  • I 就是出口,成功走出迷宫。

此时栈里面的内容就是A、B、E、G、I,因此 A->B->E->G->I 就是走出迷宫的路径。通过深度优先搜索,我们不仅可以定位到迷宫的出口,还可以记录下相关的路径信息。

现在大家知道了深度优先搜索的过程可以转化为一系列的入栈、出栈操作。那么深度优先搜索在编码上一般会如何实现呢?这里,就需要大家回忆一下第 5 节的内容了——DFS 中,我们往往使用递归来模拟入栈、出栈的逻辑。

面试官追问

追问 1演示页把待办栈直接渲染成「当前路线」,结果画出了不相连的节点,为什么?
参考回答

一次把所有邻居都压进去,栈里就混着好几条分支的候选,本来就不是一条路。要画路线,要么栈帧里存 { node, nextIndex } 一次只推进一个邻居,要么另外维护 path 数组,进入时 push、退回时 pop。

追问 2递归在一条很长的走廊上报了 Maximum call stack size exceeded,怎么改?
参考回答

改成手写栈,状态放到堆上的数组里,深度只受内存限制。如果还要精确模拟「试完一个邻居再试下一个」,每个栈帧要存节点和当前试到第几个邻居。

追问 3产品要求严格从上到下探索岔路,手写栈要注意什么?
参考回答

邻居要倒着压,最上面那个最后压、最先弹。正着压的话实际顺序全反了,结果可能不受影响,但演示动画会和设计稿对不上。

追问 4迷宫找出口,DFS 和 BFS 都能做,你会选哪个?
参考回答

只要「有没有出口、随便一条路」就用 DFS,代码短、内存只跟路径深度有关。要「最少几步」必须 BFS,DFS 先找到的那条可能绕了一大圈。